Skip to content

03 镜像构建与优化

你写了一个Dockerfile,构建出来的镜像有800MB。同事的同样功能只有200MB。差距在哪里?在于对镜像分层和构建缓存的理解。

搞懂这两个机制,你就能写出构建快、体积小、安全性高的镜像。

一、镜像分层机制

镜像不是一个整体文件,而是由多个层(layer)叠加而成的。每一条Dockerfile指令会生成一个新的层。

来看一个例子:

dockerfile
FROM ubuntu:24.04          # 第1层:基础Ubuntu系统
RUN apt-get update && apt-get install -y python3  # 第2层:安装Python
COPY requirements.txt ./   # 第3层:复制依赖文件
RUN pip install -r requirements.txt  # 第4层:安装pip依赖
COPY src ./src             # 第5层:复制源代码

构建完成后,镜像长这样:

┌─────────────────────┐
│  第5层:源代码        │  ← 最新变更
├─────────────────────┤
│  第4层:pip依赖       │
├─────────────────────┤
│  第3层:requirements │
├─────────────────────┤
│  第2层:Python运行时  │
├─────────────────────┤
│  第1层:Ubuntu系统    │  ← 基础层
└─────────────────────┘

每一层都是只读的,一旦创建就不可修改。层与层之间是叠加关系,上层的文件会覆盖下层同名文件。

1.1 查看镜像层

bash
docker image history python:3.12-alpine

输出会列出每一层的创建命令和大小。用--no-trunc可以看到完整的命令。

1.2 层的复用

层是按内容寻址的——相同内容的层只会存储一份。如果你有两个镜像都基于python:3.12-alpine,它们共享基础层,不需要重复存储。

这就是为什么Alpine基础镜像这么流行——它小,所有基于它的镜像都能省空间。

二、构建缓存

Docker构建镜像时会检查每一层是否有缓存。如果某一层的输入和之前构建时完全一致,Docker直接用缓存结果,跳过执行。

这就是为什么第二次构建比第一次快得多——大部分层都命中了缓存。

2.1 缓存失效规则

三种情况会导致缓存失效:

  1. RUN指令的命令变了——Docker发现命令字符串不同,重新执行
  2. COPYADD的源文件变了——Docker检查文件内容和属性,有变化就重建
  3. 前一层失效,后续层全部失效——层是链式依赖的,底层变了上层全部重建

第三点是关键。如果你在第一层改了东西,后面所有层都要重建,缓存全废。

2.2 优化Dockerfile利用缓存

来看一个反面教材:

dockerfile
FROM python:3.12-alpine
WORKDIR /app
COPY . .                              # 把所有文件复制进来
RUN pip install -r requirements.txt   # 安装依赖
CMD ["python", "src/main.py"]

问题在哪?COPY . .会复制所有文件,包括你的源代码。你每次改一行代码,COPY这层就失效,后面的pip install也要重装——即使requirements.txt根本没变。

优化后的写法:

dockerfile
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt ./             # 先只复制依赖文件
RUN pip install -r requirements.txt  # 安装依赖
COPY . .                             # 再复制所有文件
CMD ["python", "src/main.py"]

这样改代码时,COPY requirements.txt这层不变,pip install命中缓存,只需要重建最后一层COPY . .

实测效果:

场景优化前优化后
首次构建30秒30秒
改了代码30秒2秒
改了依赖30秒30秒

改代码从30秒变2秒,这就是缓存的威力。

2.3 .dockerignore和缓存

.dockerignore也会影响缓存。如果你的构建上下文里有.git目录或node_modules,它们的变化会导致缓存失效。排除它们能让缓存更稳定。

三、多阶段构建

先来看一个问题。假设你要构建一个Java应用:

dockerfile
FROM eclipse-temurin:21-jdk-jammy
WORKDIR /app
COPY . .
RUN ./mvnw clean install
CMD ["java", "-jar", "target/app.jar"]

构建出来的镜像有多大?880MB。因为JDK、Maven、构建工具全打包进去了。但运行时只需要JRE和那个jar包,编译器根本用不到。

多阶段构建就是为了解决这个问题:在一个Dockerfile里分多个阶段,编译在一个阶段,运行在另一个阶段,只把编译产物复制到最终镜像。

3.1 基本语法

dockerfile
# 第一阶段:构建
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app
COPY . .
RUN ./mvnw clean install

# 第二阶段:运行
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=builder /app/target/*.jar ./app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]

关键点:

  • 第一阶段用AS builder命名
  • 第二阶段用COPY --from=builder从第一阶段复制文件
  • 最终镜像只包含第二阶段的内容

构建结果:

方式镜像大小
单阶段880MB
多阶段428MB

体积直接减半。 因为最终镜像只有JRE和jar包,没有JDK和Maven。

3.2 Python项目的多阶段构建

Python项目也能用多阶段构建:

dockerfile
# 第一阶段:安装编译依赖
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# 第二阶段:运行
FROM python:3.12-alpine
WORKDIR /app
COPY --from=builder /install /usr/local
COPY src ./src
USER app
EXPOSE 8080
CMD ["python", "src/main.py"]

第一阶段用完整的Python镜像来编译需要C编译器的包(比如numpy),第二阶段用Alpine镜像只包含运行时需要的文件。

3.3 只构建某个阶段

bash
# 只构建builder阶段(调试用)
docker build --target builder -t my-app:debug .

不加--target时,默认构建最后一个阶段。

四、镜像安全

4.1 不要用root运行

dockerfile
# 创建用户
RUN addgroup -S app && adduser -S app -G app

# 切换用户
USER app

容器默认用root运行。如果容器被攻破,攻击者就有root权限。用非root用户能把风险限制在用户权限范围内。

4.2 只读文件系统

bash
docker run --read-only my-app

加上--read-only标志,容器的文件系统变成只读。应用需要写入的目录(如/tmp)用tmpfs挂载。

4.3 定期重建镜像

镜像是不可变的快照。基础镜像里的安全漏洞不会自动修复。定期用--pull重建镜像获取最新的安全补丁:

bash
docker build --pull -t my-app:1.0 .

五、总结

镜像优化的核心手段:

手段效果
利用构建缓存加快重复构建速度
多阶段构建大幅减小镜像体积
Alpine基础镜像减小基础层体积
非root用户提高安全性
.dockerignore减小构建上下文,提高缓存稳定性
定期重建获取安全补丁

记住一个原则:最终镜像里只放运行应用需要的东西。 编译器、构建工具、源代码、测试文件——这些都不应该出现在生产镜像里。

下一篇我们学习Docker Compose——当你的Agent服务需要Redis、数据库、Nginx等多个容器一起工作时,Docker Compose让你用一个YAML文件管理整个应用栈。